import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import plotly.express as px
import plotly.graph_objects as go
import os
# Загрузка данных в зависимости от среды выполнения (vscode, jupiter, collab.net)
file_id = '1-0S3t50XV_X7b4AIY_h3cRiBZKdZpObz'
google_drive_url='https://drive.google.com/uc?export=download&confirm=no_antivirus&id='
local_file = './data/dst-3.0_16_1_hh_database.zip'
remote_url = google_drive_url + file_id
# загружаем локальный датасет, если есть. если нет - скачиваем с google.drive
if os.path.exists('./data') and os.path.exists(local_file):
print('Load local:', local_file)
url = local_file
else:
print('Load remote:', remote_url)
url = remote_url
# Ускорим перезагрузку если датасет уже загружен
hh_df : pd.DataFrame
if 'hh_df' in globals():
hh_data = hh_df.copy()
print('Reload copy from memory')
else:
hh_df = pd.read_csv(url, compression='zip', sep=';')
hh_data = hh_df.copy()
print('Loaded')
Load local: ./data/dst-3.0_16_1_hh_database.zip Loaded
display(hh_data)
| Пол, возраст | ЗП | Ищет работу на должность: | Город, переезд, командировки | Занятость | График | Опыт работы | Последнее/нынешнее место работы | Последняя/нынешняя должность | Образование и ВУЗ | Обновление резюме | Авто | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Мужчина , 39 лет , родился 27 ноября 1979 | 29000 руб. | Системный администратор | Советск (Калининградская область) , не готов к... | частичная занятость, проектная работа, полная ... | гибкий график, полный день, сменный график, ва... | Опыт работы 16 лет 10 месяцев Август 2010 — п... | МАОУ "СОШ № 1 г.Немана" | Системный администратор | Неоконченное высшее образование 2000 Балтийск... | 16.04.2019 15:59 | Имеется собственный автомобиль |
| 1 | Мужчина , 60 лет , родился 20 марта 1959 | 40000 руб. | Технический писатель | Королев , не готов к переезду , готов к редким... | частичная занятость, проектная работа, полная ... | гибкий график, полный день, сменный график, уд... | Опыт работы 19 лет 5 месяцев Январь 2000 — по... | Временный трудовой коллектив | Менеджер проекта, Аналитик, Технический писатель | Высшее образование 1981 Военно-космическая ак... | 12.04.2019 08:42 | Не указано |
| 2 | Женщина , 36 лет , родилась 12 августа 1982 | 20000 руб. | Оператор | Тверь , не готова к переезду , не готова к ком... | полная занятость | полный день | Опыт работы 10 лет 3 месяца Октябрь 2004 — Де... | ПАО Сбербанк | Кассир-операционист | Среднее специальное образование 2002 Профессио... | 16.04.2019 08:35 | Не указано |
| 3 | Мужчина , 38 лет , родился 25 июня 1980 | 100000 руб. | Веб-разработчик (HTML / CSS / JS / PHP / базы ... | Саратов , не готов к переезду , готов к редким... | частичная занятость, проектная работа, полная ... | гибкий график, удаленная работа | Опыт работы 18 лет 9 месяцев Август 2017 — Ап... | OpenSoft | Инженер-программист | Высшее образование 2002 Саратовский государст... | 08.04.2019 14:23 | Не указано |
| 4 | Женщина , 26 лет , родилась 3 марта 1993 | 140000 руб. | Региональный менеджер по продажам | Москва , не готова к переезду , готова к коман... | полная занятость | полный день | Опыт работы 5 лет 7 месяцев Региональный мене... | Мармелад | Менеджер по продажам | Высшее образование 2015 Кгу Психологии и педаг... | 22.04.2019 10:32 | Не указано |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 44739 | Мужчина , 30 лет , родился 17 января 1989 | 50000 руб. | Финансист, аналитик, экономист, бухгалтер, мен... | Тверь , готов к переезду (Москва, Химки) , гот... | полная занятость | полный день, удаленная работа | Опыт работы 7 лет 7 месяцев Финансист, аналит... | ООО "IAS" (независимый участник объединения Ru... | Руководитель субгруппы | Высшее образование 2015 Московский гуманитарн... | 22.04.2019 12:32 | Не указано |
| 44740 | Мужчина , 27 лет , родился 5 марта 1992 | 39000 руб. | Системный администратор, IT-специалист | Липецк , готов к переезду , готов к командировкам | проектная работа, частичная занятость, полная ... | удаленная работа, гибкий график, полный день, ... | Опыт работы 7 лет Системный администратор, IT... | ИП Пестрецов | Предприниматель | Высшее образование (Бакалавр) 2016 Воронежски... | 22.04.2019 13:11 | Не указано |
| 44741 | Женщина , 48 лет , родилась 26 декабря 1970 | 40000 руб. | Аналитик данных, Математик | Челябинск , готова к переезду , готова к редки... | полная занятость | полный день, удаленная работа | Опыт работы 21 год 5 месяцев Январь 1998 — по... | ОАО «ЧМК», Исследовательско-Технологический Це... | Начальник группы аналитики | Высшее образование 2000 Южно-Уральский госуда... | 09.04.2019 05:07 | Не указано |
| 44742 | Мужчина , 24 года , родился 6 октября 1994 | 20000 руб. | Контент-менеджер | Тамбов , не готов к переезду , не готов к кома... | частичная занятость, полная занятость | удаленная работа | Опыт работы 3 года 10 месяцев Контент-менедже... | IQ-Maxima | Менеджер проектов | Высшее образование 2015 Тамбовский государств... | 26.04.2019 14:25 | Имеется собственный автомобиль |
| 44743 | Мужчина , 38 лет , родился 25 апреля 1980 | 120000 руб. | Руководитель проекта | Москва , не готов к переезду , не готов к кома... | полная занятость | полный день | Опыт работы 15 лет 10 месяцев Руководитель пр... | ПАО ГК ТНС энерго | Руководитель отдела технической поддержки | Высшее образование 1997 Южно-Российский госуд... | 05.07.2018 20:15 | Не указано |
44744 rows × 12 columns
print('NaN`s Column')
for colm in hh_data.columns:
cnt_na = hh_data[colm].isna().sum()
if cnt_na: print(f'{cnt_na:5} ', colm)
hh_data.info()
NaN`s Column
168 Опыт работы
1 Последнее/нынешнее место работы
2 Последняя/нынешняя должность
<class 'pandas.core.frame.DataFrame'>
RangeIndex: 44744 entries, 0 to 44743
Data columns (total 12 columns):
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 Пол, возраст 44744 non-null object
1 ЗП 44744 non-null object
2 Ищет работу на должность: 44744 non-null object
3 Город, переезд, командировки 44744 non-null object
4 Занятость 44744 non-null object
5 График 44744 non-null object
6 Опыт работы 44576 non-null object
7 Последнее/нынешнее место работы 44743 non-null object
8 Последняя/нынешняя должность 44742 non-null object
9 Образование и ВУЗ 44744 non-null object
10 Обновление резюме 44744 non-null object
11 Авто 44744 non-null object
dtypes: object(12)
memory usage: 4.1+ MB
hh_data.describe(include='object')
| Пол, возраст | ЗП | Ищет работу на должность: | Город, переезд, командировки | Занятость | График | Опыт работы | Последнее/нынешнее место работы | Последняя/нынешняя должность | Образование и ВУЗ | Обновление резюме | Авто | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| count | 44744 | 44744 | 44744 | 44744 | 44744 | 44744 | 44576 | 44743 | 44742 | 44744 | 44744 | 44744 |
| unique | 16003 | 690 | 14929 | 10063 | 38 | 47 | 44413 | 30214 | 16927 | 40148 | 18838 | 2 |
| top | Мужчина , 32 года , родился 17 сентября 1986 | 50000 руб. | Системный администратор | Москва , не готов к переезду , не готов к кома... | полная занятость | полный день | Опыт работы 10 лет 8 месяцев Апрель 2018 — по... | Индивидуальное предпринимательство / частная п... | Системный администратор | Высшее образование 1987 Военный инженерный Кра... | 07.05.2019 09:50 | Не указано |
| freq | 18 | 4064 | 3099 | 1261 | 30026 | 22727 | 3 | 935 | 2062 | 4 | 25 | 32268 |
# Сохраним преобразование и удалим исходный признак
hh_data['Образование'] = hh_data['Образование и ВУЗ'].apply(lambda s : s[:s.find(' образование')].lower())
hh_data.drop(columns='Образование и ВУЗ', inplace=True)
# Оценим
display( hh_data['Образование'].value_counts() )
высшее 33863 среднее специальное 5765 неоконченное высшее 4557 среднее 559 Name: Образование, dtype: int64
Извлечение информации. Создание раздельных признаков
# Выберем нужные признаки и вернем кортеджем (Пол,Возраст)
def extract_person_info(raw:str) -> tuple:
info = [s.strip() for s in raw.split(',')]
return info[0][:1].upper(), int(info[1].split()[0])
# Сделаем парсинг за раз. Загрузим в промежуточный датафрэйм
person_info = pd.DataFrame( hh_data['Пол, возраст'].apply(extract_person_info).to_list(),
columns=['sex','age'] )
# Сохраним в исходный датафрейм
hh_data['Пол'] = person_info.sex
hh_data['Возраст'] = person_info.age
# Очистим память и удалим исходниый признак
del person_info
hh_data.drop(columns='Пол, возраст', inplace=True)
# проверим типы признаков
hh_data[['Пол', 'Возраст']].info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 44744 entries, 0 to 44743 Data columns (total 2 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Пол 44744 non-null object 1 Возраст 44744 non-null int64 dtypes: int64(1), object(1) memory usage: 699.2+ KB
print('Распределение резюме по полу (%):')
print( (hh_data['Пол'].value_counts(normalize=True) * 100).round(2))
print('Cредний возраст соискателей:', round(hh_data['Возраст'].mean(), 1))
Распределение резюме по полу (%): М 80.93 Ж 19.07 Name: Пол, dtype: float64 Cредний возраст соискателей: 32.2
# Проверим на пропуски
print('Кол-во NaN:' ,hh_data['Опыт работы'].isna().sum())
# Парсинг строки с опытом работы
def extract_experience(raw:str) -> int:
"""Преобразует входящую строку с опытом работы
в годах и месяцах в целое - количество месяцев
"""
template_begin = 'Опыт работы'
template_begin_len = len(template_begin)
# Если NaN или "неформат" - отдаем NaN
if raw is np.nan:
return raw
elif not raw.startswith(template_begin):
return np.nan
else:
# Удаляем остаток строки с доп.инфо
space2x_pos = raw.find(' ')
template_end_pos = space2x_pos if space2x_pos != -1 else None
raw_extract = raw[template_begin_len + 1 : template_end_pos].split()
# проверяем на варианты, отдаем в месяцах.
if raw_extract[1][:5] == 'месяц':
return int(raw_extract[0])
elif raw_extract[1][:3] in ('год', 'лет'):
if len(raw_extract) > 3 and raw_extract[3][:5] == 'месяц':
return int(raw_extract[0])*12 + int(raw_extract[2])
else:
return int(raw_extract[0])*12
else:
return np.nan
# Преобразуем и сохраним в исходный датасет
hh_data['Опыт работы (месяц)'] = hh_data['Опыт работы'].apply(extract_experience)
# оценим визуально
# display( hh_data[['Опыт работы', 'Опыт работы (месяц)',]].sample(10) )
# и удалим исходный признак
hh_data.drop(columns='Опыт работы', inplace=True)
# к заданию
print('Медиана опыта работы (месяцев):', round(hh_data['Опыт работы (месяц)'].median()))
# и оценим на прирост кол-ва пропусков
print('Кол-во NaN:' ,hh_data['Опыт работы (месяц)'].isna().sum())
Кол-во NaN: 168 Медиана опыта работы (месяцев): 100 Кол-во NaN: 170
Извлечение информации, создание признаков-индикаторов
# Удалим лишнюю информацию в скобках
def remove_bracket(raw:str) -> tuple:
"""Удаляет скобки с содержимым"""
bracket_counter = 0
result = ''
for ch in raw:
if ch == '(':
bracket_counter += 1
if ch == ')':
bracket_counter -= 1
elif not bracket_counter:
result += ch
return [x.strip() for x in result.split(',', maxsplit=1)]
# Загрузим раздельно город и информацию в промежуточный датафрэйм за один проход
reloc_df = pd.DataFrame(
data=hh_data['Город, переезд, командировки'].apply(remove_bracket).to_list(),
columns=('city','reinfo')
)
# Удалим исходный признак
hh_data.drop(columns='Город, переезд, командировки', inplace=True)
# Удалим информацию о станции метро, если есть. остаток вернем списком (переезд, коман-ки)
# +1 NaN в списке - защита от отсутсвия запятой последнего блока
def extract_reloc(raw:str) -> tuple:
"""Разделяет информацию о готовности
с удалением информации о метро"""
if raw.startswith('м. '):
result = [x.strip() for x in raw.split(',')[1:]] + [np.nan]
else:
result = [x.strip() for x in raw.split(',')] + [np.nan]
return result[:2]
# Загрузим в промежуточный датафрэйм за один проход
reloc_info_df = pd.DataFrame(
data=reloc_df.reinfo.apply(extract_reloc).to_list(),
columns=('relocate','worktrip')
)
# Удаляем исходный признак
reloc_df.drop(columns='reinfo', inplace=True)
# сохраняем готовность к переезду в признак-индикатор по (не]
reloc_info_df['relocate_indicator'] = reloc_info_df['relocate'].apply( lambda x : x[:2] != 'не' )
# сохраняем готовность к командировками в признак-индикатор по (г] учитывая наличие NaN и ""
# если "г" - все же "мусор", отбераем по (готов]
reloc_info_df['worktrip_indicator'] = reloc_info_df['worktrip'].apply(
lambda x : True if x is not np.nan and len(x) and x[0] == 'г' else False
)
# Формируем признак "Город"
million_cities = ['Новосибирск', 'Екатеринбург', 'Нижний Новгород', 'Казань', 'Челябинск', 'Омск', 'Самара', 'Ростов-на-Дону', 'Уфа', 'Красноярск', 'Пермь', 'Воронеж', 'Волгоград' ]
capitals = ['Москва', 'Санкт-Петербург']
def location_category(loc_name):
"""Преобразует наименование населеного пункта
в тип-категорию"""
if loc_name in capitals:
return loc_name
elif loc_name in million_cities:
return 'город-миллионник'
else:
return 'другие'
# Сохраним
reloc_df['location'] = reloc_df.city.apply(location_category)
# присоеденим инфо о месте локации, переезде и командировке
reloc_df = reloc_df.join(reloc_info_df)
#удалим промежуточный датасет
del reloc_info_df
# Сохраним в исходный
hh_data['Город'] = reloc_df.location
hh_data['Готовность к переезду'] = reloc_df.relocate_indicator
hh_data['Готовность к командировкам'] =reloc_df.worktrip_indicator
# и удалим промежуточный датасет
del reloc_df
# Ответы к заданиям
display(hh_data['Город'].value_counts(normalize=True).mul(100).round(2))
display(hh_data[['Готовность к переезду', 'Готовность к командировкам']] \
.value_counts(normalize=True).mul(100).round() )
# Или
mask = hh_data['Готовность к переезду'] & hh_data['Готовность к командировкам']
print('Готовы одновременно к переездам и командировкам (%):',
round(hh_data[mask].shape[0] / hh_data.shape[0] * 100))
Москва 37.15 другие 35.43 город-миллионник 16.39 Санкт-Петербург 11.03 Name: Город, dtype: float64
Готовность к переезду Готовность к командировкам False True 39.0 True True 32.0 False False 25.0 True False 4.0 dtype: float64
Готовы одновременно к переездам и командировкам (%): 32
Создание признаков-индикаторов из списка значений переменной длины в исходном признаке.
#(!) Прототип механизма кодирование описан в первой части черновика (explore-a.ipynb)
# и здесь не описывается для краткости по требованиям задания
# Сделаем выборку наборов значений в множества. избавимся от внешние пробелов
ss_predict = hh_data['Занятость'].apply(lambda raw: {x.strip() for x in raw.split(',')})
#display(ss_predict)
# Отберем полный набор значений для индексов
idx_set = set()
for i in ss_predict:
idx_set.update(i)
#print(idx_set)
# исходный Series с "выключенными" индикаторами
ssbase = pd.Series(False, index=list(idx_set))
# обработчик - генератор Series row c "включенными" индикаторами
def extract_cross_typeset(raw:set) -> pd.Series:
row = ssbase.copy()
row.update(pd.Series(True, index=list(raw)))
return row
# (!) Плата за универсальность - относительно долгое время обработки. 30-40 сек.
ohr_data = ss_predict.apply(extract_cross_typeset)
# Закрепим полученный результат
hh_data = hh_data.join(ohr_data).drop(columns='Занятость')
display(ohr_data.info())
<class 'pandas.core.frame.DataFrame'> RangeIndex: 44744 entries, 0 to 44743 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 полная занятость 44744 non-null bool 1 стажировка 44744 non-null bool 2 частичная занятость 44744 non-null bool 3 волонтерство 44744 non-null bool 4 проектная работа 44744 non-null bool dtypes: bool(5) memory usage: 218.6 KB
None
# Повторим для "График"
ss_predict = hh_data['График'].apply(lambda raw: {x.strip() for x in raw.split(',')})
#display(ss_predict)
idx_set = set()
for i in ss_predict:
idx_set.update(i)
#print(idx_set)
#
ssbase = pd.Series(False, index=list(idx_set))
# Функция-обработчик та же, ssbase - ссылка
ohr_data = ss_predict.apply(extract_cross_typeset)
# Закрепим полученный результат и удалим исходные и промежуточные данные
hh_data = hh_data.join(ohr_data).drop(columns='График')
display(ohr_data.info())
del ohr_data, ss_predict
# Все признаки-индикаторы
#print('All row count:',hh_data.shape[0])
display(
hh_data[[c for c in hh_data.columns if hh_data[c].dtype.name=='bool']] \
.sum().sort_values(ascending=False) \
.to_frame(name='Кол-во').style.bar(align='mid')
)
# К заданиям
mask = hh_data['проектная работа'] & hh_data['волонтерство']
print('Готовы на проектную работу и волонтёрство:', mask.sum())
mask = hh_data['вахтовый метод'] & hh_data['гибкий график']
print('Ищут вахтовый метод и гибкий график:', mask.sum())
del mask
<class 'pandas.core.frame.DataFrame'> RangeIndex: 44744 entries, 0 to 44743 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 гибкий график 44744 non-null bool 1 полный день 44744 non-null bool 2 сменный график 44744 non-null bool 3 вахтовый метод 44744 non-null bool 4 удаленная работа 44744 non-null bool dtypes: bool(5) memory usage: 218.6 KB
None
| Кол-во | |
|---|---|
| полная занятость | 43284 |
| полный день | 41716 |
| Готовность к командировкам | 31646 |
| Готовность к переезду | 16025 |
| гибкий график | 15584 |
| удаленная работа | 15022 |
| частичная занятость | 13136 |
| сменный график | 12725 |
| проектная работа | 8068 |
| вахтовый метод | 3084 |
| стажировка | 2804 |
| волонтерство | 486 |
Готовы на проектную работу и волонтёрство: 436 Ищут вахтовый метод и гибкий график: 2311
Преобразование желаемой заработной платы из национальных в единую валюту (в рубли).
# Загрузка курсов валют на даты обновления резюме
local_file = './data/ExchangeRates.zip'
file_id = '13r8O_ynFNdnWgU7ELZ2jqVeKLTWhV9Ci'
remote_url = google_drive_url + file_id
# есть локальный - берем локальный, нет - тянем с гугл диска
if os.path.exists('./data') and os.path.exists(local_file):
print('Load local:', local_file)
url = local_file
else:
print('Load remote:', remote_url)
url = remote_url
# Ускорим перезагрузку если уже "стянули"
exchange_df : pd.DataFrame
if 'exchange_df' in globals():
exchange_data = exchange_df.copy()
print('Reload copy from memory')
else:
exchange_df = pd.read_csv(url, compression='zip', sep=',')
exchange_data = exchange_df.copy()
print('Loaded')
Load local: ./data/ExchangeRates.zip Loaded
# Таблица валют и пропорций конвертации из задания
# для получения ISO кода валюты из сокращенного наименования.
# От сюда же возмем пропорцию (CurrencyFactor) для конвертора.
convertor_data = pd.DataFrame(
data=[['грн', 'UAH', 10, 'гривна'],
['USD', 'USD', 1, 'доллар'],
['EUR', 'EUR', 1, 'евро'],
['белруб', 'BYN', 1, 'белорусский рубль'],
['KGS', 'KGS', 10, 'киргизский сом'],
['сум', 'UZS', 10_000, 'узбекский сум'],
['AZN', 'AZN', 1, 'азербайджанский манат'],
['KZT', 'KZT', 100, 'казахстанский тенге'],
['руб', 'RUB', 1, 'российский рубль']],
columns=['ID', 'ISO', 'CF', 'NAME'],
).set_index('ID')
# Разбираем ЗП на сумму и валюту. Готовим дату для конвертора
salary_df = hh_data[['ЗП', 'Обновление резюме']].copy()
salary_df['Валюта'] = salary_df['ЗП'].apply(lambda x: x.split()[1].replace('.',''))
salary_df['ЗП'] = salary_df['ЗП'].apply(lambda x: x.split()[0]).astype('float')
salary_df['Обновление резюме'] = pd.to_datetime(salary_df['Обновление резюме'], format='%d.%m.%Y %H:%M').dt.date
# Оценим
# display( salary_df.sample(3) )
display( salary_df['Валюта'].value_counts() )
# дату в ISO
exchange_data['date'] = pd.to_datetime(exchange_data.date).dt.date #.normalize()
# И сразу установим индекс для JOIN
exchange_data.set_index(keys=['currency', 'date'], inplace=True)
# Погружаем тип валюты в ISO и пропорции для конвертации
currency_df = salary_df \
.join(convertor_data[['ISO', 'CF']], on='Валюта') \
.rename(columns={'ISO':'currency','Обновление резюме':'date'}) \
.join(exchange_data[['close']], on=['currency', 'date'] )
# добовляем рубль
currency_df.loc[currency_df.currency=='RUB','close'] = 1.0
currency_df['close'] = currency_df['close'].astype('float64')
# Конвертируем в рубли
currency_df['salary'] = currency_df.apply(lambda row: row['ЗП'] * row['close'] / row['CF'], axis=1)
# Оценим конвертацию в целом (по факту выпадают почти одни RUB)
display(currency_df.sample(5))
# и в национальных валютах
no_rub_mask = currency_df['Валюта'] != 'руб'
display(currency_df[no_rub_mask].sample(5))
# Сохраняем в исходный датасет
hh_data['ЗП (руб)'] = currency_df.salary
# и удаляем исходные столбцы и промежуточные данные
hh_data.drop(columns='ЗП', inplace=True)
del exchange_df, salary_df, currency_df, convertor_data, no_rub_mask
# к заданию
print('Желаемая медианная заработная\nплата соискателей: ',
round(hh_data['ЗП (руб)'].median()/1000), 'тыс.руб.')
руб 42471 KZT 1108 USD 628 белруб 329 EUR 106 грн 73 сум 20 KGS 6 AZN 3 Name: Валюта, dtype: int64
| ЗП | date | Валюта | currency | CF | close | salary | |
|---|---|---|---|---|---|---|---|
| 10181 | 100000.0 | 2019-04-05 | руб | RUB | 1 | 1.0 | 100000.0 |
| 36038 | 60000.0 | 2019-04-26 | руб | RUB | 1 | 1.0 | 60000.0 |
| 24151 | 60000.0 | 2019-04-15 | руб | RUB | 1 | 1.0 | 60000.0 |
| 20003 | 60000.0 | 2019-04-20 | руб | RUB | 1 | 1.0 | 60000.0 |
| 16635 | 45000.0 | 2019-05-07 | руб | RUB | 1 | 1.0 | 45000.0 |
| ЗП | date | Валюта | currency | CF | close | salary | |
|---|---|---|---|---|---|---|---|
| 4116 | 900.0 | 2019-05-07 | USD | USD | 1 | 63.4013 | 57061.17 |
| 16041 | 1200.0 | 2019-04-16 | EUR | EUR | 1 | 72.7018 | 87242.16 |
| 36782 | 300.0 | 2019-04-22 | белруб | BYN | 1 | 30.5314 | 9159.42 |
| 42287 | 300000.0 | 2019-04-25 | KZT | KZT | 100 | 16.8799 | 50639.70 |
| 35513 | 750.0 | 2019-04-22 | белруб | BYN | 1 | 30.5314 | 22898.55 |
Желаемая медианная заработная плата соискателей: 59 тыс.руб.
hh_data.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 44744 entries, 0 to 44743 Data columns (total 23 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 Ищет работу на должность: 44744 non-null object 1 Последнее/нынешнее место работы 44743 non-null object 2 Последняя/нынешняя должность 44742 non-null object 3 Обновление резюме 44744 non-null object 4 Авто 44744 non-null object 5 Образование 44744 non-null object 6 Пол 44744 non-null object 7 Возраст 44744 non-null int64 8 Опыт работы (месяц) 44574 non-null float64 9 Город 44744 non-null object 10 Готовность к переезду 44744 non-null bool 11 Готовность к командировкам 44744 non-null bool 12 полная занятость 44744 non-null bool 13 стажировка 44744 non-null bool 14 частичная занятость 44744 non-null bool 15 волонтерство 44744 non-null bool 16 проектная работа 44744 non-null bool 17 гибкий график 44744 non-null bool 18 полный день 44744 non-null bool 19 сменный график 44744 non-null bool 20 вахтовый метод 44744 non-null bool 21 удаленная работа 44744 non-null bool 22 ЗП (руб) 44744 non-null float64 dtypes: bool(12), float64(2), int64(1), object(8) memory usage: 4.3+ MB
# Переведем в категориальный тип признаки, с которыми будем работать
hh_data['Образование'] = hh_data['Образование'].astype('category')
hh_data['Город'] = hh_data['Город'].astype('category')
hh_data['Ищет работу на должность:'] = hh_data['Ищет работу на должность:'].astype('category')
hh_data['Обновление резюме'] = pd.to_datetime(hh_data['Обновление резюме']).dt.date
fig = px.histogram( data_frame=hh_data,
height=540, # width=700,
title='Распределение возраста соискателей',
x='Возраст',
#nbins=100,
marginal='box',
#histnorm='percent',
).update_layout(bargap=0.05)
fig.show()
Вывод
fig = px.histogram( data_frame=hh_data, height=540, # width=700,
title='Распределение опыта работы соскателя (месяцы)',
x='Опыт работы (месяц)',
#nbins=100,
marginal='box',
#histnorm='percent',
)#.update_layout(bargap=0.05)
fig.update_layout(bargap=0.05)
fig.show()
Вывод
fig = px.histogram( data_frame=hh_data , # hh_data[mask]
height=540, # width=700,
title='Распределение желаемой заработной платы соискателя (руб)',
x='ЗП (руб)',
marginal='box',
#nbins=100,
#histnorm='percent',
).update_layout(bargap=0.05)
fig.show()
#Удалим выбросы, улучшим наглядность визуализации для анализа
mask = hh_data['ЗП (руб)'] <= 1E6
fig = px.histogram( data_frame=hh_data[mask] , # hh_data[mask]
height=540, # width=700,
title='Распределение желаемой заработной платы соискателя (руб) (< 1 мил.руб.)',
x='ЗП (руб)',
marginal='box',
#nbins=100,
#histnorm='percent',
).update_layout(bargap=0.05)
fig.show()
del mask
Вывод
mask = hh_data['ЗП (руб)'] < 1e6
fig = px.bar(
data_frame=hh_data[mask].groupby('Образование')[['ЗП (руб)']].median().reset_index(),
title='Зависимость медианной желаемой заработной платы от уровня образования',
x='Образование',
y='ЗП (руб)',
color='Образование'
)
fig.show()
del mask
Вывод
mask = hh_data['ЗП (руб)'] < 1e6
fig = px.box(
data_frame=hh_data[mask],
title='Зависимость медианной желаемой заработной платы от места проживания',
x='ЗП (руб)',
y='Город',
color='Город'
)
fig.show()
del mask
Вывод
# сгруппируем и локализуем значения признаков
group_by = hh_data.groupby(['Готовность к переезду','Готовность к командировкам'])['ЗП (руб)'].median().reset_index()
group_by['Готовность к переезду'].replace({True:'Да', False:'Нет'}, inplace=True)
group_by['Готовность к командировкам'].replace({True:'Да', False:'Нет'}, inplace=True)
fig = px.bar(
data_frame=group_by,
title='Медианный уровень желаемой зароботной платы<br>от готовности к переезду и командировкам',
x='Готовность к командировкам',
y='ЗП (руб)',
color='Готовность к переезду',
barmode='group',
)
#fig.update_xaxes(type='category', categoryorder='category ascending')
fig.show()
del group_by
Вывод
fig = px.imshow(
hh_data.pivot_table(
columns='Возраст', index='Образование',
values='ЗП (руб)', aggfunc=np.median ),
title="Тепловая карта зависимости медианной желаемой заработной платы от возраста и образования",
labels={'color':'ЗП (руб)'},
color_continuous_scale='YlOrRd', # 'Viridis' 'Blues' 'RdBu'
)
fig.show()
Вывод
hh_data['Опыт работы'] = (hh_data['Опыт работы (месяц)'] /12).round(1)
fig = px.scatter( data_frame=hh_data.assign(_vsize=1),
height=740, # width=820,
title='Зависимость опыта работы от возраста',
x='Возраст', y='Опыт работы', color='Образование',
opacity=0.5, size='_vsize', size_max=8,
hover_data={'_vsize':False},
range_x=[-5, 105], range_y=[-5, 105],
)
fig.add_trace( go.Scatter(x=[-5,105], y=[-5,105], mode='lines', name='Опыт работы равен возрасту',
line = {'color':'red','width':1.5})
)
fig.add_trace( go.Scatter(x=[11,121], y=[-5,105], mode='lines', name='Опыт работы (Возраст-16)',
line = {'color':'orange','width':1.5})
)
fig.show()
#(!) Обязательно удаляем доп. признаки для анализа, иначе кол-во дубликатов и т.п.
# будут не соответствовать тестовым значениям. Или используем df.assign(...) для графиков
hh_data.drop(columns='Опыт работы', inplace=True)
Вывод
дополнительное задание
#уберем выброс для наглядности
mask_age = hh_data['Возраст'] < 100
# Сформируем признак-категорию готовности
def mobile_alacrity_predict(row:pd.Series)->str:
if row['Готовность к переезду'] and row['Готовность к командировкам']:
return 'К переезду и командировкам'
elif not row['Готовность к переезду'] and row['Готовность к командировкам']:
return 'Только к переезду'
elif row['Готовность к переезду'] and not row['Готовность к командировкам']:
return 'Только к командировкам'
else:
return 'Отсутствует'
hh_data['Готовность'] = hh_data.apply(mobile_alacrity_predict, axis=1)
fig = px.histogram( data_frame= hh_data[mask_age],
height=740, # width=700,
title='Распределение возраста соскателей по группам<br>готовых к смене места жительства и командировкам',
x='Возраст',
color='Готовность',
marginal='box',
barmode='overlay',
).update_layout(bargap=0.05)
fig.show()
del mask_age
#(!) Обязательно удаляем доп. признаки для анализа, иначе кол-во дубликатов и т.п.
# будут не соответствовать тестовым значениям. Или используем df.assign(...) для графиков
hh_data.drop(columns='Готовность', inplace=True)
Вывод
дополнительное задание
# Ограничемся реалистичной минимальной зарплатой в 12т (близко к МРОТ)
# и более реалистичными зарплатами в максимуме. Топ менеджеры вряд ли ищут работу через hh.ru
mask_salary_bw = (hh_data['ЗП (руб)'] > 12_000) & (hh_data['ЗП (руб)'] < 350_000)
# Отберем в категории топ 25 искомых должностей по количеству соскателей
top25_jobs = hh_data.groupby('Ищет работу на должность:')['ЗП (руб)'] \
.agg(['count', 'median']).nlargest(25, columns='count')
# Выберем из них топ 10 самых больших ожидаемых медианных зарплат
top10_salary = top25_jobs.nlargest(10, columns='median').index
# display(top25_jobs.nlargest(10, columns='median'))
# Аналитики лидирую по количеству, руководители проектов по з/п. И это топ 10. Кризис, однако.
#отбираем и рисуем
top10_salary_mask = hh_data['Ищет работу на должность:'].isin(top10_salary)
fig = px.box(
height=720, # width=700,
data_frame=hh_data[mask_salary_bw & top10_salary_mask],
title='Распределение желаемой заработной платы в топ 10 ожидаемых<br>медианных зарплат из топ 25 искомых должностей',
x='ЗП (руб)',
y='Ищет работу на должность:',
color='Ищет работу на должность:'
).update_layout(showlegend=False)
fig.show()
del mask_salary_bw, top25_jobs, top10_salary, top10_salary_mask
Вывод
#Далее работаем с копией данных для очистки
hh_cleaned = hh_data.copy()
# Ищем дубликаты, выводим их количество
hh_duplicates = hh_cleaned[hh_cleaned.duplicated()]
print('Кол-во полных дубликатов:', hh_duplicates.shape[0])
# и удаляем. смотрим что осталось. (44744-161)
hh_cleaned.drop_duplicates(inplace=True)
print('Результирующее число записей:', hh_cleaned.shape[0])
Кол-во полных дубликатов: 161 Результирующее число записей: 44583
print('Признаки с пропусками:')
print('NaN`s Column')
for colm in hh_cleaned.columns:
cnt_na = hh_cleaned[colm].isna().sum()
if cnt_na: print(f'{cnt_na:5} ', colm)
Признаки с пропусками:
NaN`s Column
1 Последнее/нынешнее место работы
2 Последняя/нынешняя должность
168 Опыт работы (месяц)
# удалим столбцы с малым кол-вом пропусков
subset = ['Последнее/нынешнее место работы', 'Последняя/нынешняя должность']
hh_cleaned.dropna(subset=subset, inplace=True)
# заполняем медианым значением признак со значительными пропусками
hh_cleaned.fillna({'Опыт работы (месяц)': hh_cleaned['Опыт работы (месяц)'].median()}, inplace=True)
# Проверяем
print('Кол-во пропусков "Опыт работы":', hh_cleaned['Опыт работы (месяц)'].isna().sum())
# И в целом
print('Результирующее число записей:', hh_cleaned.shape[0])
print('Общее кол-во пропусков:',hh_cleaned.isna().sum().sum())
print('Среднее "Опыт работы (месяц)":', hh_cleaned['Опыт работы (месяц)'].mean())
Кол-во пропусков "Опыт работы": 0 Результирующее число записей: 44581 Общее кол-во пропусков: 0 Среднее "Опыт работы (месяц)": 114.35777573405711
# Отфильтруем, оценим и удалим
mask_not_bw_1k_1m = (hh_cleaned['ЗП (руб)'] < 1E3) | (hh_cleaned['ЗП (руб)'] > 1E6)
print('Результирующее число записей до очистки:', hh_cleaned.shape[0])
print('Кол-во выбросов в "ЗП" менее 1K и более 1M:', hh_cleaned[mask_not_bw_1k_1m].shape[0])
hh_cleaned.drop(index=hh_cleaned[mask_not_bw_1k_1m].index, inplace=True)
del mask_not_bw_1k_1m
print('Результирующее число записей после очистки:', hh_cleaned.shape[0])
Результирующее число записей до очистки: 44581 Кол-во выбросов в "ЗП" менее 1K и более 1M: 89 Результирующее число записей после очистки: 44492
mask_work_over_age = hh_cleaned['Опыт работы (месяц)'] > hh_cleaned['Возраст']*12
print('Результирующее число записей до очистки:', hh_cleaned.shape[0])
print('Кол-во выбросов с превышением опыта работы:', hh_cleaned[mask_work_over_age].shape[0])
hh_cleaned.drop(index=hh_cleaned[mask_work_over_age].index, inplace=True)
del mask_work_over_age
print('Результирующее число записей после очистки:', hh_cleaned.shape[0])
Результирующее число записей до очистки: 44492 Кол-во выбросов с превышением опыта работы: 7 Результирующее число записей после очистки: 44485
# Очистка от выбросов в признаке "Возраст" методом трех сигм
# Сперва посмотрим распределение на графике в логарифмическом маштабе
hh_cleaned['Возраст (лог)'] = np.log(hh_cleaned['Возраст'])
# Заготовочка с курса и в право 4 сигмы
mu = hh_cleaned['Возраст (лог)'].mean()
sigma = hh_cleaned['Возраст (лог)'].std()
left_shift = 3
right_shift = 4
lower_bound = mu - left_shift * sigma
upper_bound = mu + right_shift * sigma
fig = px.histogram( data_frame=hh_cleaned,
height=540, # width=700,
title='Распределение возраста соискателей (логарифмический маштаб)',
x='Возраст (лог)',
marginal='box',
range_y=[0, 3000],
).update_layout(bargap=0.05)
# (!) Для отображения результата на графике наведите курсор в нижную часть линии
fig.add_trace( go.Scatter(x=[mu,mu], y=[0,3000], mode='lines', name='Mean',
line = {'color':'red','width':2}))
fig.add_trace( go.Scatter(x=[lower_bound, lower_bound], y=[0,3000], mode='lines', name='Lower bound',
line = {'color':'green','width':2}))
fig.add_trace( go.Scatter(x=[upper_bound, upper_bound], y=[0,3000], mode='lines', name='Upper bound',
line = {'color':'orange','width':2}))
fig.show()
# Выводим список значений выбросов
mask_log_age_emissions = (hh_cleaned['Возраст (лог)'] < lower_bound) | (hh_cleaned['Возраст (лог)'] > upper_bound)
print('Кол-во выбросов "Возраст (лог)":', mask_log_age_emissions.sum())
display(hh_cleaned[mask_log_age_emissions][['Возраст (лог)','Возраст']])
print('Нижняя граница (срд-3*сигмы):', round(np.exp(lower_bound)))
print('Верхняя граница (срд+4*сигмы):', round(np.exp(upper_bound)))
# Удостоверимся что лог. распределение лево-симмитричное, как на графике
print('Кооф. асимметрии:', round(hh_cleaned['Возраст (лог)'].skew(), 2))
#Удаляем выбросы
hh_cleaned.drop(index=hh_cleaned[mask_log_age_emissions].index, inplace=True)
del mask_log_age_emissions
hh_cleaned.drop(columns='Возраст (лог)', inplace=True)
Кол-во выбросов "Возраст (лог)": 3
| Возраст (лог) | Возраст | |
|---|---|---|
| 31137 | 2.70805 | 15 |
| 32950 | 2.70805 | 15 |
| 33654 | 4.60517 | 100 |
Нижняя граница (срд-3*сигмы): 16 Верхняя граница (срд+4*сигмы): 79 Кооф. асимметрии: 0.45
Вывод
print('Результирующее число записей до очистки:', hh_data.shape[0])
print('Результирующее число записей после очистки:', hh_cleaned.shape[0])
print('Удалено записей:', hh_data.shape[0] - hh_cleaned.shape[0])
Результирующее число записей до очистки: 44744 Результирующее число записей после очистки: 44482 Удалено записей: 262
*В современном мире, при повсеместном использовании средст цифровой обработки и автоматизации множества процессов, разнообразии форм и содержание различных данных для анализа предпологает не только отличное знание инструментальных средст и математического аппарата, но умение использовать эмпирические методы, основанные на здравом смысле, жизненном опыте и
практических навыках. И визуальный анализ данных является отличным инструментом к таким методам.
Эвристика и творческий подход к обработке данных могут значительно улучшить качество результата.*